NexusPi Git Node
Commit 5e0e3f538af6523b3c1960013638692061d46a61
Parents : 990649d
Author : James L <jrl290@gmail.com>
Date : 2026-02-26T14:32:18-05:00
v1.0.5: Heltec V3 support, heap stability fix, TCP reconnection improvements
- Add Heltec WiFi LoRa 32 V3 board support (8MB flash, 8MB PSRAM)
- New heltec_V3_boundary build environment in platformio.ini
- Board auto-detection in flash.py (8MB=V3, 16MB=V4)
- V3 board definition in Boards.h
- Fix heap exhaustion causing watchdog reboots every ~70 min
- Lower boundary_mentioned_addresses cap from 512 to 200
- Heap now stable at ~38KB free (was draining to 0)
- TCP reconnection improvements in TcpInterface.h
- SO_LINGER(0) for clean socket teardown
- 10-minute read timeout prevents zombie connections
- Defensive client cleanup on accept
- Add heap telemetry instrumentation (HEAP-TEL) for monitoring
- Add level guards on TRACE/DEBUG macros in Log.h
- Update README for dual V3/V4 board support
Changes
8 files changed, 424 insertions(+), 111 deletions(-)
Diff
diff --git a/Boards.h b/Boards.h
index 38262d8..e319b63 100755
--- a/Boards.h
+++ b/Boards.h
@@ -349,7 +349,11 @@
#define HAS_DISPLAY true
#define HAS_WIFI true
#define HAS_BLUETOOTH false
- #define HAS_BLE true
+ #ifdef BOUNDARY_MODE
+ #define HAS_BLE false
+ #else
+ #define HAS_BLE true
+ #endif
#define HAS_PMU true
#define HAS_CONSOLE true
#define HAS_EEPROM true
diff --git a/README.md b/README.md
index c2d7574..d57fb8e 100755
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V4
+# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V3 / V4
-A custom firmware for the **Heltec WiFi LoRa 32 V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi.
+A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi.
```
Android / Sideband Remote
@@ -28,18 +28,22 @@ Built on [microReticulum](https://github.com/attermann/microReticulum) (a C++ po
- **Optional local TCP server** — serve local devices on your WiFi in addition to the backbone connection
- **Automatic reconnection** — WiFi and TCP connections recover from drops with exponential backoff
- **ESP32 memory-optimized** — table sizes, timeouts, and caching tuned for the constrained MCU environment
+- **Dual board support** — supports both Heltec V3 (8MB flash, 8MB PSRAM) and V4 (16MB flash, 2MB PSRAM) with automatic board detection
## Hardware
-The **Heltec WiFi LoRa 32 V4** was chosen because it ships standard with **2 MB PSRAM** and **16 MB flash** — enough headroom for the microReticulum transport tables, packet caching to flash storage, and the web-based configuration portal. Many other LoRa dev boards come with only 4–8 MB flash and no PSRAM, which would require significant compromises to the boundary node's caching and routing capabilities.
+Both the **Heltec WiFi LoRa 32 V3** and **V4** are supported. These boards were chosen because they ship with PSRAM and ample flash — enough headroom for the microReticulum transport tables, packet caching to flash storage, and the web-based configuration portal. Many other LoRa dev boards come with only 4 MB flash and no PSRAM, which would require significant compromises to the boundary node's caching and routing capabilities.
-| Component | Spec |
-|-----------|------|
-| **Board** | Heltec WiFi LoRa 32 V4 |
-| **MCU** | ESP32-S3, 2MB PSRAM, 16MB Flash |
-| **Radio** | SX1262 + GC1109 PA (up to 28 dBm) |
-| **Display** | SSD1306 OLED 128×64 |
-| **WiFi** | 2.4 GHz 802.11 b/g/n |
+| Component | Heltec V3 | Heltec V4 |
+|-----------|-----------|----------|
+| **MCU** | ESP32-S3 | ESP32-S3 |
+| **Flash** | 8 MB | 16 MB |
+| **PSRAM** | 8 MB (QSPI) | 2 MB (QSPI) |
+| **Radio** | SX1262 | SX1262 + GC1109 PA |
+| **TX Power** | Up to 22 dBm | Up to 28 dBm |
+| **Display** | SSD1306 OLED 128×64 | SSD1306 OLED 128×64 |
+| **WiFi** | 2.4 GHz 802.11 b/g/n | 2.4 GHz 802.11 b/g/n |
+| **USB** | Native USB CDC | Native USB CDC |
## Quick Start
@@ -56,13 +60,18 @@ git clone https://github.com/jrl290/RNodeTHV4.git
cd RNodeTHV4
# Download latest firmware from GitHub Releases and flash
+# (auto-detects V3 vs V4 from flash size)
python flash.py --download
+# Or specify board explicitly
+python flash.py --download --board v3
+python flash.py --download --board v4
+
# Or flash a local binary
python flash.py --file rnodethv4_firmware.bin
```
-The flash utility will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode.
+The flash utility auto-detects whether a V3 or V4 is connected by querying the flash size (8MB = V3, 16MB = V4). You can override with `--board v3` or `--board v4`. It will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode.
### Option B: Build from Source (PlatformIO)
@@ -74,15 +83,18 @@ For development or customization:
git clone https://github.com/jrl290/RNodeTHV4.git
cd RNodeTHV4
-# Build
+# Build for V4
pio run -e heltec_V4_boundary
+# Build for V3
+pio run -e heltec_V3_boundary
+
# Flash (via PlatformIO)
pio run -e heltec_V4_boundary -t upload
# Or create a merged binary and flash with the utility
-python flash.py --merge-only # creates rnodethv4_firmware.bin
-python flash.py # flash it
+python flash.py --merge-only # creates merged firmware bin
+python flash.py # flash it (auto-detects board)
# Monitor serial output (optional)
pio device monitor -e heltec_V4_boundary
@@ -326,8 +338,8 @@ Set the boundary node's **Local TCP Server** to **Enabled** (port 4242).
| `TcpInterface.h` | TCP interface for both backbone and local server (implements `RNS::InterfaceImpl`) with HDLC framing, unique naming, and 10 Mbps bitrate |
| `Display.h` | OLED display layout — boundary-specific status page |
| `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware |
-| `Boards.h` | Board variant definition for `heltec32v4_boundary` |
-| `platformio.ini` | Build targets: `heltec_V4_boundary` and `heltec_V4_boundary-local` |
+| `Boards.h` | Board variant definitions for V3 and V4 |
+| `platformio.ini` | Build targets: `heltec_V3_boundary`, `heltec_V4_boundary`, and `heltec_V4_boundary-local` |
### Library Patches
@@ -340,12 +352,12 @@ The firmware depends on [microReticulum](https://github.com/attermann/microRetic
| `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` |
| `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts |
-### Memory Usage (typical)
+### Memory Usage (typical, V4)
| Resource | Used | Available |
-|----------|------|-----------|
+|----------|------|----------|
| RAM | ~21.7% | 320 KB |
-| Flash | ~18.1% | 16 MB |
+| Flash | ~18.4% | 16 MB |
| PSRAM | Dynamic | 2 MB |
## License
diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino
index ae070db..b9afe48 100755
--- a/RNode_Firmware.ino
+++ b/RNode_Firmware.ino
@@ -635,7 +635,8 @@ void setup() {
RNS::Transport::set_transmit_packet_callback(on_transmit_packet);
Serial.write("Starting RNS...\r\n");
- RNS::loglevel(RNS::LOG_TRACE);
+ RNS::loglevel(RNS::LOG_VERBOSE);
+ //RNS::loglevel(RNS::LOG_TRACE);
//RNS::loglevel(RNS::LOG_MEM);
HEAD("Registering LoRA Interface...", RNS::LOG_TRACE);
@@ -701,6 +702,9 @@ void setup() {
0,
"LocalTcpInterface"
);
+ // rnsd can be quiet for long stretches — use 10 min timeout
+ // to prevent unnecessary reconnection cycles that leak lwIP memory
+ local_tcp_interface_ptr->setReadTimeout(600000);
local_tcp_rns_interface = local_tcp_interface_ptr;
local_tcp_rns_interface.mode(RNS::Type::Interface::MODE_ACCESS_POINT);
RNS::Transport::register_interface(local_tcp_rns_interface);
diff --git a/TcpInterface.h b/TcpInterface.h
index 46b4155..f1bd147 100755
--- a/TcpInterface.h
+++ b/TcpInterface.h
@@ -17,6 +17,7 @@
#ifdef BOUNDARY_MODE
#include <WiFi.h>
+#include <lwip/sockets.h> // SO_LINGER — force RST to free lwIP PCBs immediately
#include <Interface.h>
#include <Transport.h>
#include <Bytes.h>
@@ -75,6 +76,7 @@ public:
_last_reconnect(0),
_last_keepalive(0),
_reconnect_interval(TCP_IF_RECONNECT_MIN),
+ _read_timeout(TCP_IF_READ_TIMEOUT),
_resolved_ip((uint32_t)0),
_consecutive_failures(0),
_started(false)
@@ -128,7 +130,16 @@ public:
void stop() {
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
if (_clients[i].active) {
+ // Force RST to free lwIP PCBs immediately (no TIME_WAIT)
+ int fd = _clients[i].client.fd();
+ if (fd >= 0) {
+ struct linger lin;
+ lin.l_onoff = 1;
+ lin.l_linger = 0;
+ setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin));
+ }
_clients[i].client.stop();
+ _clients[i].client = WiFiClient();
_clients[i].active = false;
}
}
@@ -185,25 +196,15 @@ public:
if (!_clients[i].active) continue;
if (!_clients[i].client.connected()) {
- Serial.printf("[TcpIF] Client %d disconnected\r\n", i);
- _clients[i].client.stop();
- _clients[i].active = false;
- _clients[i].in_frame = false;
- _clients[i].escape = false;
- _clients[i].rxlen = 0;
- _num_clients--;
+ _cleanup_client(i, "disconnected");
continue;
}
- // Check read timeout
- if (_clients[i].last_activity > 0 &&
- (millis() - _clients[i].last_activity) > TCP_IF_READ_TIMEOUT) {
- Serial.printf("[TcpIF] Client %d read timeout\r\n", i);
- _clients[i].client.stop();
- _clients[i].active = false;
- _clients[i].in_frame = false;
- _clients[i].rxlen = 0;
- _num_clients--;
+ // Check read timeout (0 = disabled)
+ if (_read_timeout > 0 &&
+ _clients[i].last_activity > 0 &&
+ (millis() - _clients[i].last_activity) > _read_timeout) {
+ _cleanup_client(i, "read timeout");
continue;
}
@@ -220,6 +221,7 @@ public:
int clientCount() const { return _num_clients; }
bool isStarted() const { return _started; }
bool isConnected() const { return _num_clients > 0; }
+ void setReadTimeout(uint32_t timeout_ms) { _read_timeout = timeout_ms; }
protected:
// ─── RNS InterfaceImpl: outgoing packet from RNS Transport ───────────────
@@ -248,12 +250,7 @@ protected:
if (_clients[i].active && _clients[i].client.connected()) {
size_t written = _clients[i].client.write(frame_buf, flen);
if (written == 0) {
- Serial.printf("[TcpIF] Write failed on client %d, dropping\r\n", i);
- _clients[i].client.stop();
- _clients[i].active = false;
- _clients[i].in_frame = false;
- _clients[i].rxlen = 0;
- _num_clients--;
+ _cleanup_client(i, "write failed");
}
}
}
@@ -270,6 +267,38 @@ protected:
}
private:
+ // ─── Cleanup a client slot, freeing all lwIP resources ───────────────────
+ void _cleanup_client(int idx, const char* reason) {
+ TcpClient& c = _clients[idx];
+ if (!c.active) return;
+
+ uint32_t heap_before = ESP.getFreeHeap();
+
+ // Set SO_LINGER with timeout 0: forces RST instead of FIN,
+ // which skips TIME_WAIT and immediately frees the lwIP PCB
+ // and all associated TCP send/receive buffers (~2-4 KB each).
+ int fd = c.client.fd();
+ if (fd >= 0) {
+ struct linger lin;
+ lin.l_onoff = 1;
+ lin.l_linger = 0;
+ setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin));
+ }
+
+ c.client.stop();
+ c.client = WiFiClient(); // Release any residual shared_ptr state
+ c.active = false;
+ c.in_frame = false;
+ c.escape = false;
+ c.rxlen = 0;
+ _num_clients--;
+
+ uint32_t heap_after = ESP.getFreeHeap();
+ Serial.printf("[TcpIF] Client %d %s (heap: %u -> %u, delta: %+d)\r\n",
+ idx, reason, heap_before, heap_after,
+ (int)(heap_after - heap_before));
+ }
+
// ─── HDLC byte-level deframing ──────────────────────────────────────────
void _hdlc_deframe(int idx, uint8_t byte) {
TcpClient& c = _clients[idx];
@@ -306,6 +335,18 @@ private:
// Find a free slot
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
if (!_clients[i].active) {
+ // Defensive: force-release any residual lwIP resources in this slot
+ // before assigning the new client (prevents PCB/buffer leaks)
+ int fd = _clients[i].client.fd();
+ if (fd >= 0) {
+ struct linger lin;
+ lin.l_onoff = 1;
+ lin.l_linger = 0;
+ setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin));
+ _clients[i].client.stop();
+ }
+ _clients[i].client = WiFiClient(); // Reset to clean state
+
_clients[i].client = newClient;
_clients[i].client.setNoDelay(true);
_clients[i].client.setTimeout(TCP_IF_WRITE_TIMEOUT / 1000);
@@ -399,6 +440,7 @@ private:
uint32_t _last_reconnect;
uint32_t _last_keepalive;
uint32_t _reconnect_interval;
+ uint32_t _read_timeout;
IPAddress _resolved_ip;
uint16_t _consecutive_failures;
bool _started;
diff --git a/flash.py b/flash.py
index edb47b5..3204780 100755
--- a/flash.py
+++ b/flash.py
@@ -2,16 +2,19 @@
"""
RNodeTHV4 Flash Utility
-Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V4.
+Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V3 or V4.
No PlatformIO required — just Python 3 and a USB cable.
Default mode flashes only the app partition (0x10000), preserving
bootloader, partition table, NVS, and EEPROM settings.
Usage:
- # Update firmware (preserves WiFi/boundary settings)
+ # Update firmware — V4 (default)
python flash.py
+ # Update firmware — V3
+ python flash.py --board v3
+
# Full flash with merged binary (overwrites everything)
python flash.py --full
@@ -43,9 +46,6 @@ import time
CHIP = "esp32s3"
FLASH_MODE = "qio"
FLASH_FREQ = "80m"
-FLASH_SIZE = "16MB"
-BAUD_RATE = "921600"
-MERGED_FILENAME = "rnodethv4_firmware.bin"
GITHUB_REPO = "jrl290/RNodeTHV4"
# Flash addresses for ESP32-S3 Arduino framework
@@ -54,11 +54,61 @@ PARTITIONS_ADDR = 0x8000
BOOT_APP0_ADDR = 0xe000
APP_ADDR = 0x10000
-# PlatformIO build output paths (relative to project root)
-BUILD_DIR = ".pio/build/heltec_V4_boundary"
-BOOTLOADER_BIN = os.path.join(BUILD_DIR, "bootloader.bin")
-PARTITIONS_BIN = os.path.join(BUILD_DIR, "partitions.bin")
-FIRMWARE_BIN = os.path.join(BUILD_DIR, "rnode_firmware_heltec32v4_boundary.bin")
+# ── Board profiles ─────────────────────────────────────────────────────────────
+# Each board defines its PIO env, flash size, baud rate, firmware binary name,
+# and merged binary name.
+
+BOARD_PROFILES = {
+ "v4": {
+ "name": "Heltec WiFi LoRa 32 V4",
+ "pio_env": "heltec_V4_boundary",
+ "build_dir": ".pio/build/heltec_V4_boundary",
+ "firmware_bin": "rnode_firmware_heltec32v4_boundary.bin",
+ "merged_filename": "rnodethv4_firmware.bin",
+ "flash_size": "16MB",
+ "baud_rate": "921600",
+ },
+ "v3": {
+ "name": "Heltec WiFi LoRa 32 V3",
+ "pio_env": "heltec_V3_boundary",
+ "build_dir": ".pio/build/heltec_V3_boundary",
+ "firmware_bin": "rnode_firmware_heltec32v3.bin",
+ "merged_filename": "rnodethv3_firmware.bin",
+ "flash_size": "8MB",
+ "baud_rate": "460800",
+ },
+}
+DEFAULT_BOARD = "v4"
+
+# Active board profile (set in main() from --board arg)
+_board = None
+
+def board_profile():
+ return BOARD_PROFILES[_board or DEFAULT_BOARD]
+
+def BUILD_DIR():
+ return board_profile()["build_dir"]
+
+def BOOTLOADER_BIN():
+ return os.path.join(BUILD_DIR(), "bootloader.bin")
+
+def PARTITIONS_BIN():
+ return os.path.join(BUILD_DIR(), "partitions.bin")
+
+def FIRMWARE_BIN():
+ return os.path.join(BUILD_DIR(), board_profile()["firmware_bin"])
+
+def FLASH_SIZE():
+ return board_profile()["flash_size"]
+
+def BAUD_RATE():
+ return board_profile()["baud_rate"]
+
+def MERGED_FILENAME():
+ return board_profile()["merged_filename"]
+
+def PIO_ENV():
+ return board_profile()["pio_env"]
# ESP32 partition table magic bytes (first two bytes of a partition table entry)
PARTITION_TABLE_MAGIC = b'\xaa\x50'
@@ -94,6 +144,16 @@ def _find_in_platformio_or_release(build_path, release_name):
return None
+# Forward-compatible aliases (these are now functions, not constants)
+def _bootloader_bin():
+ return BOOTLOADER_BIN()
+
+def _partitions_bin():
+ return PARTITIONS_BIN()
+
+def _firmware_bin():
+ return FIRMWARE_BIN()
+
def find_boot_app0():
"""Find boot_app0.bin from PlatformIO framework packages.
@@ -126,16 +186,77 @@ def find_boot_app0():
def find_bootloader():
"""Find bootloader.bin from PlatformIO build output or Release/ bundle."""
- return _find_in_platformio_or_release(BOOTLOADER_BIN, "bootloader.bin")
+ return _find_in_platformio_or_release(BOOTLOADER_BIN(), "bootloader.bin")
def find_partitions():
"""Find partitions.bin from PlatformIO build output or Release/ bundle."""
- return _find_in_platformio_or_release(PARTITIONS_BIN, "partitions.bin")
+ return _find_in_platformio_or_release(PARTITIONS_BIN(), "partitions.bin")
BOOT_APP0_BIN = find_boot_app0()
+# ── Board auto-detection ───────────────────────────────────────────────────────
+
+# Map detected flash sizes to board keys
+_FLASH_SIZE_TO_BOARD = {
+ "16MB": "v4",
+ "8MB": "v3",
+}
+
+def detect_board(port, esptool_cmd):
+ """Auto-detect which Heltec board is connected by querying flash size.
+
+ Runs ``esptool.py flash_id`` and parses the output for:
+ - Detected flash size (16MB → V4, 8MB → V3)
+ - Chip type (ESP32-S3 expected)
+ - Features (PSRAM size, WiFi, BLE)
+
+ Returns a tuple (board_key, info_dict) on success, or (None, reason) on
+ failure. ``board_key`` is "v3" or "v4".
+ """
+ cmd = esptool_cmd + ["--port", port, "flash_id"]
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ except subprocess.TimeoutExpired:
+ return None, "esptool timed out (device not responding?)"
+ except Exception as e:
+ return None, str(e)
+
+ output = result.stdout + result.stderr
+ if result.returncode != 0:
+ return None, f"esptool flash_id failed:\n{output.strip()}"
+
+ # Parse key fields
+ info = {}
+ for line in output.splitlines():
+ line = line.strip()
+ if line.startswith("Chip is "):
+ info["chip"] = line[len("Chip is "):]
+ elif line.startswith("Features:"):
+ info["features"] = line[len("Features:"):].strip()
+ elif line.startswith("Detected flash size:"):
+ info["flash_size"] = line.split(":")[-1].strip()
+ elif line.startswith("MAC:"):
+ info["mac"] = line.split(":")[-5:] # last 5 colon-groups
+ info["mac"] = line[len("MAC:"):].strip()
+ elif line.startswith("Crystal is"):
+ info["crystal"] = line[len("Crystal is"):].strip()
+
+ flash_size = info.get("flash_size")
+ if not flash_size:
+ return None, f"Could not parse flash size from esptool output:\n{output.strip()}"
+
+ board_key = _FLASH_SIZE_TO_BOARD.get(flash_size)
+ if not board_key:
+ return None, (
+ f"Unknown flash size '{flash_size}' — expected 16MB (V4) or 8MB (V3).\n"
+ f"Use --board v3 or --board v4 to specify manually."
+ )
+
+ return board_key, info
+
+
# ── Helpers ────────────────────────────────────────────────────────────────────
def find_esptool():
@@ -259,16 +380,16 @@ def download_firmware(dest_path):
# Find the merged firmware asset
asset_url = None
for asset in release.get("assets", []):
- if asset["name"] == MERGED_FILENAME:
+ if asset["name"] == MERGED_FILENAME():
asset_url = asset["browser_download_url"]
break
if not asset_url:
- print(f"Error: '{MERGED_FILENAME}' not found in latest release ({release.get('tag_name', '?')}).")
+ print(f"Error: '{MERGED_FILENAME()}' not found in latest release ({release.get('tag_name', '?')}).")
print("Available assets:", [a["name"] for a in release.get("assets", [])])
return False
- print(f"Downloading {release['tag_name']} / {MERGED_FILENAME}...")
+ print(f"Downloading {release['tag_name']} / {MERGED_FILENAME()}...")
try:
urlretrieve(asset_url, dest_path)
except Exception as e:
@@ -293,7 +414,7 @@ def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmw
"merge_bin",
"--flash_mode", FLASH_MODE,
"--flash_freq", FLASH_FREQ,
- "--flash_size", FLASH_SIZE,
+ "--flash_size", FLASH_SIZE(),
"-o", output_path,
f"0x{BOOTLOADER_ADDR:x}", bootloader,
f"0x{PARTITIONS_ADDR:x}", partitions,
@@ -321,11 +442,11 @@ def merge_firmware(output_path, esptool_cmd):
bootloader = find_bootloader()
partitions = find_partitions()
boot_app0 = BOOT_APP0_BIN
- firmware = FIRMWARE_BIN
+ firmware = FIRMWARE_BIN()
missing = []
- if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN))
- if not partitions: missing.append(("partitions", PARTITIONS_BIN))
+ if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN()))
+ if not partitions: missing.append(("partitions", PARTITIONS_BIN()))
if not boot_app0: missing.append(("boot_app0", "(not found)"))
if not os.path.isfile(firmware):
missing.append(("firmware", firmware))
@@ -333,7 +454,7 @@ def merge_firmware(output_path, esptool_cmd):
if missing:
for name, path in missing:
print(f"Error: {name} not found: {path}")
- print("Run 'pio run -e heltec_V4_boundary' to build first.")
+ print(f"Run 'pio run -e {PIO_ENV()}' to build first.")
return False
return _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware)
@@ -360,7 +481,7 @@ def auto_merge_app_binary(app_binary_path, esptool_cmd):
if missing:
print(f"Cannot auto-merge: missing {', '.join(missing)}")
print("Place them in the Release/ folder alongside flash.py, or")
- print("build with PlatformIO: pio run -e heltec_V4_boundary")
+ print(f"build with PlatformIO: pio run -e {PIO_ENV()}")
return None
# Create merged binary next to the app binary
@@ -406,10 +527,13 @@ def reset_to_bootloader(port):
return True
-def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE):
+def flash_firmware(firmware_path, port, esptool_cmd, baud=None):
"""Flash firmware to the device."""
+ if baud is None:
+ baud = BAUD_RATE()
+ flash_size = FLASH_SIZE()
print(f"\nFlashing {firmware_path} to {port}...")
- print(f" Chip: {CHIP} Baud: {baud} Flash: {FLASH_SIZE}\n")
+ print(f" Chip: {CHIP} Baud: {baud} Flash: {flash_size}\n")
# Determine if this is a merged binary (flash at 0x0) or app-only (flash at 0x10000)
is_merged = is_merged_binary(firmware_path)
@@ -431,7 +555,7 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE):
"-z",
"--flash_mode", FLASH_MODE,
"--flash_freq", FLASH_FREQ,
- "--flash_size", FLASH_SIZE,
+ "--flash_size", flash_size,
flash_addr, firmware_path,
]
@@ -443,12 +567,14 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE):
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
+ global _board
parser = argparse.ArgumentParser(
- description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V4",
+ description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V3/V4",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
- python flash.py # App-only update (preserves settings)
+ python flash.py # App-only update, V4 (default)
+ python flash.py --board v3 # App-only update, V3
python flash.py --full # Full flash with merged binary
python flash.py --download # Download latest release and flash
python flash.py --file firmware.bin # Flash a specific file
@@ -457,9 +583,12 @@ Examples:
python flash.py --erase --full # Erase flash, then full flash
""",
)
+ parser.add_argument("--board", choices=["v3", "v4"], default=None,
+ help="Target board: v3 (Heltec V3) or v4 (Heltec V4). "
+ "Auto-detected from connected device if omitted.")
parser.add_argument("--file", "-f", help="Path to firmware binary to flash")
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
- parser.add_argument("--baud", "-b", default=BAUD_RATE, help=f"Baud rate (default: {BAUD_RATE})")
+ parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
parser.add_argument("--download", "-d", action="store_true",
help="Download latest firmware from GitHub Releases")
parser.add_argument("--merge-only", action="store_true",
@@ -470,20 +599,58 @@ Examples:
help="Erase entire flash before writing (implies --full)")
args = parser.parse_args()
- baud = args.baud
- print("╔══════════════════════════════════════════╗")
- print("║ RNodeTHV4 Flash Utility ║")
- print("║ Heltec WiFi LoRa 32 V4 Boundary Node ║")
- print("╚══════════════════════════════════════════╝")
- print()
-
- # Find esptool
+ # Find esptool early — needed for both auto-detect and flashing
esptool_cmd = find_esptool()
if not esptool_cmd:
print("Error: esptool not found!")
print("Install it with: pip install esptool")
sys.exit(1)
+
+ # ── Board detection ─────────────────────────────────────────────────
+ detected_info = None
+ _early_port = None
+
+ if args.board:
+ # Explicit board — no detection needed
+ _board = args.board
+ elif args.merge_only:
+ # No device needed for merge — fall back to default
+ _board = DEFAULT_BOARD
+ print(f"(No --board specified; defaulting to {DEFAULT_BOARD} for merge)")
+ else:
+ # Auto-detect from connected device
+ _early_port = args.port or find_serial_port()
+ if not _early_port:
+ print("No serial port detected and no --board specified.")
+ print(f"Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.")
+ _board = DEFAULT_BOARD
+ else:
+ print(f"Detecting board on {_early_port}...")
+ board_key, info = detect_board(_early_port, esptool_cmd)
+ if board_key:
+ _board = board_key
+ detected_info = info
+ print(f" Chip: {info.get('chip', '?')}")
+ print(f" Flash: {info.get('flash_size', '?')}")
+ print(f" Features: {info.get('features', '?')}")
+ print(f" MAC: {info.get('mac', '?')}")
+ print(f" → Detected: {BOARD_PROFILES[board_key]['name']}")
+ else:
+ reason = info # info is the error reason when board_key is None
+ print(f" Auto-detect failed: {reason}")
+ print(f" Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.")
+ _board = DEFAULT_BOARD
+
+ baud = args.baud or BAUD_RATE()
+ bp = board_profile()
+
+ print()
+ print("╔══════════════════════════════════════════╗")
+ print("║ RNodeTHV4 Flash Utility ║")
+ print(f"║ {bp['name']:^40s} ║")
+ print("╚══════════════════════════════════════════╝")
+ print()
print(f"Using esptool: {' '.join(esptool_cmd)}")
# --erase implies --full (after erase, device needs bootloader + partitions)
@@ -492,6 +659,9 @@ Examples:
# Determine firmware file
firmware_path = None
+ merged_fn = MERGED_FILENAME()
+ firmware_bin = FIRMWARE_BIN()
+ pio_env = PIO_ENV()
if args.file:
firmware_path = args.file
@@ -500,69 +670,69 @@ Examples:
sys.exit(1)
elif args.download:
- firmware_path = MERGED_FILENAME
+ firmware_path = merged_fn
if not download_firmware(firmware_path):
sys.exit(1)
elif args.merge_only:
- if merge_firmware(MERGED_FILENAME, esptool_cmd):
- print(f"\nDone! Flash with: python flash.py --file {MERGED_FILENAME}")
+ if merge_firmware(merged_fn, esptool_cmd):
+ print(f"\nDone! Flash with: python flash.py --board {_board} --file {merged_fn}")
else:
sys.exit(1)
return
elif args.full:
# Full flash: use or create merged binary
- if os.path.isfile(FIRMWARE_BIN):
+ if os.path.isfile(firmware_bin):
# Build exists — (re-)merge
- if os.path.isfile(MERGED_FILENAME):
- build_time = os.path.getmtime(FIRMWARE_BIN)
- merge_time = os.path.getmtime(MERGED_FILENAME)
+ if os.path.isfile(merged_fn):
+ build_time = os.path.getmtime(firmware_bin)
+ merge_time = os.path.getmtime(merged_fn)
if build_time > merge_time:
print("Build output is newer than merged binary, re-merging...")
- if not merge_firmware(MERGED_FILENAME, esptool_cmd):
+ if not merge_firmware(merged_fn, esptool_cmd):
sys.exit(1)
else:
print("Creating merged binary from PlatformIO build output...")
- if not merge_firmware(MERGED_FILENAME, esptool_cmd):
+ if not merge_firmware(merged_fn, esptool_cmd):
sys.exit(1)
- firmware_path = MERGED_FILENAME
- elif os.path.isfile(MERGED_FILENAME):
- firmware_path = MERGED_FILENAME
+ firmware_path = merged_fn
+ elif os.path.isfile(merged_fn):
+ firmware_path = merged_fn
else:
print("No firmware found for full flash!")
print()
print("Options:")
- print(" 1. Build with PlatformIO first: pio run -e heltec_V4_boundary")
- print(" 2. Download from GitHub: python flash.py --download")
- print(" 3. Specify a file: python flash.py --file <path>")
+ print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
+ print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
+ print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
sys.exit(1)
else:
# Default: app-only flash (preserves settings)
- if os.path.isfile(FIRMWARE_BIN):
- firmware_path = FIRMWARE_BIN
+ if os.path.isfile(firmware_bin):
+ firmware_path = firmware_bin
print(f"App-only update (preserves WiFi/boundary settings)")
print(f" Use --full for a complete flash, or --erase for recovery.")
- elif os.path.isfile(MERGED_FILENAME):
- firmware_path = MERGED_FILENAME
- print(f"No build output found, using merged binary: {MERGED_FILENAME}")
+ elif os.path.isfile(merged_fn):
+ firmware_path = merged_fn
+ print(f"No build output found, using merged binary: {merged_fn}")
print(f" Note: merged binary will overwrite bootloader + partitions.")
else:
print("No firmware found!")
print()
print("Options:")
- print(" 1. Build with PlatformIO first: pio run -e heltec_V4_boundary")
- print(" 2. Download from GitHub: python flash.py --download")
- print(" 3. Specify a file: python flash.py --file <path>")
+ print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
+ print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
+ print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
sys.exit(1)
- # Flash
- port = args.port or find_serial_port()
+ # Flash — reuse early-detected port if available
+ port = args.port or _early_port or find_serial_port()
if not port:
print("\nError: No serial port detected!")
- print("Connect your Heltec V4 via USB and try again,")
- print("or specify manually: python flash.py --port /dev/ttyACM0")
+ print(f"Connect your {bp['name']} via USB and try again,")
+ print(f"or specify manually: python flash.py --board {_board} --port /dev/ttyACM0")
sys.exit(1)
print(f"\nSerial port: {port}")
diff --git a/lib/microReticulum/src/Log.h b/lib/microReticulum/src/Log.h
index 5caacbb..e12c578 100755
--- a/lib/microReticulum/src/Log.h
+++ b/lib/microReticulum/src/Log.h
@@ -25,10 +25,10 @@
#define VERBOSE(msg) (RNS::verbose(msg))
#define VERBOSEF(msg, ...) (RNS::verbosef(msg, __VA_ARGS__))
#ifndef NDEBUG
- #define DEBUG(msg) (RNS::debug(msg))
- #define DEBUGF(msg, ...) (RNS::debugf(msg, __VA_ARGS__))
- #define TRACE(msg) (RNS::trace(msg))
- #define TRACEF(msg, ...) (RNS::tracef(msg, __VA_ARGS__))
+ #define DEBUG(msg) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debug(msg); } while(0)
+ #define DEBUGF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debugf(msg, __VA_ARGS__); } while(0)
+ #define TRACE(msg) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::trace(msg); } while(0)
+ #define TRACEF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::tracef(msg, __VA_ARGS__); } while(0)
#if defined(RNS_MEM_LOG)
#define MEM(msg) (RNS::mem(msg))
#define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__))
diff --git a/lib/microReticulum/src/Transport.cpp b/lib/microReticulum/src/Transport.cpp
index f9e0db6..bcec600 100755
--- a/lib/microReticulum/src/Transport.cpp
+++ b/lib/microReticulum/src/Transport.cpp
@@ -104,6 +104,7 @@ using namespace RNS::Utilities;
static std::set<Bytes> _boundary_local_addresses;
// BOUNDARY MODE Whitelist 2: addresses mentioned in packets from local devices
static std::set<Bytes> _boundary_mentioned_addresses;
+static const uint16_t _boundary_maxsize = 200;
// BOUNDARY MODE: Check if an interface is the backbone
static bool is_backbone_interface(const Interface& iface) {
@@ -256,6 +257,9 @@ static bool is_backbone_interface(const Interface& iface) {
/*static*/ void Transport::jobs() {
//TRACE("Transport::jobs()");
+ // Heap telemetry: snapshot at jobs entry
+ size_t _jobs_heap_entry = OS::heap_available();
+
std::vector<Packet> outgoing;
std::set<Bytes> path_requests;
int count;
@@ -443,6 +447,15 @@ static bool is_backbone_interface(const Interface& iface) {
_packet_hashlist.erase(_packet_hashlist.begin(), iter);
}
+#ifdef BOUNDARY_MODE
+ // Cull the boundary mentioned addresses if it has reached its max size
+ if (_boundary_mentioned_addresses.size() > _boundary_maxsize) {
+ std::set<Bytes>::iterator iter = _boundary_mentioned_addresses.begin();
+ std::advance(iter, _boundary_mentioned_addresses.size() - _boundary_maxsize);
+ _boundary_mentioned_addresses.erase(_boundary_mentioned_addresses.begin(), iter);
+ }
+#endif
+
// Cull the path request tags list if it has reached its max size
if (_discovery_pr_tags.size() > _max_pr_tags) {
std::set<Bytes>::iterator iter = _discovery_pr_tags.begin();
@@ -656,6 +669,15 @@ static bool is_backbone_interface(const Interface& iface) {
_jobs_running = false;
+ // Heap telemetry: snapshot at jobs exit
+ {
+ size_t _jobs_heap_exit = OS::heap_available();
+ int _jobs_delta = (int)_jobs_heap_exit - (int)_jobs_heap_entry;
+ if (_jobs_delta < -64 || _jobs_delta > 64) {
+ VERBOSEF("[HEAP-TEL] jobs: %d bytes (heap=%u)", _jobs_delta, (uint32_t)_jobs_heap_exit);
+ }
+ }
+
// CBA send announce retransmission packets
for (auto& packet : outgoing) {
packet.send();
@@ -1201,6 +1223,9 @@ static bool is_backbone_interface(const Interface& iface) {
/*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) {
TRACEF("Transport::inbound: received %d bytes", raw.size());
++_packets_received;
+
+ // Heap telemetry: snapshot at entry
+ size_t _heap_at_entry = OS::heap_available();
// CBA
if (_callbacks._receive_packet) {
try {
@@ -1440,7 +1465,16 @@ static bool is_backbone_interface(const Interface& iface) {
}
#endif
- // CBA ACCUMULATES
+ // Heap telemetry: snapshot after boundary filter
+ {
+ size_t _heap_after_boundary = OS::heap_available();
+ int _boundary_delta = (int)_heap_after_boundary - (int)_heap_at_entry;
+ if (_boundary_delta < -64) {
+ VERBOSEF("[HEAP-TEL] boundary: %d bytes (bma=%u phl=%u)", _boundary_delta, _boundary_mentioned_addresses.size(), _packet_hashlist.size());
+ }
+ }
+
+ // CBA ACCUMULATES
_packet_hashlist.insert(packet.packet_hash());
cache_packet(packet);
@@ -2642,6 +2676,21 @@ static bool is_backbone_interface(const Interface& iface) {
}
}
+ // Heap telemetry: snapshot at exit
+ {
+ size_t _heap_at_exit = OS::heap_available();
+ int _inbound_delta = (int)_heap_at_exit - (int)_heap_at_entry;
+ // Log every 100th packet or when delta exceeds threshold
+ static uint32_t _tel_pkt_count = 0;
+ ++_tel_pkt_count;
+ if (_inbound_delta < -64 || (_tel_pkt_count % 100 == 0)) {
+ VERBOSEF("[HEAP-TEL] inbound: %d bytes (heap=%u pin=%u bma=%u phl=%u lt=%u revr=%u)",
+ _inbound_delta, (uint32_t)_heap_at_exit, _packets_received,
+ _boundary_mentioned_addresses.size(), _packet_hashlist.size(),
+ _link_table.size(), _reverse_table.size());
+ }
+ }
+
_jobs_locked = false;
}
@@ -4249,6 +4298,7 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence::
interface_announces += interface.announce_queue().size();
}
VERBOSEF("phl: %u rcp: %u lt: %u pl: %u al: %u tun: %u", _packet_hashlist.size(), _receipts.size(), _link_table.size(), _pending_links.size(), _active_links.size(), _tunnels.size());
+ VERBOSEF("bla: %u bma: %u", _boundary_local_addresses.size(), _boundary_mentioned_addresses.size());
VERBOSEF("pin: %u pout: %u padd: %u dpr: %u ikd: %u ia: %u\r\n", _packets_received, _packets_sent, _destinations_added, destination_path_responses, Identity::_known_destinations.size(), interface_announces);
_last_memory = memory;
diff --git a/platformio.ini b/platformio.ini
index fc6b28e..a3eb7f9 100755
--- a/platformio.ini
+++ b/platformio.ini
@@ -309,6 +309,37 @@ lib_deps =
${env.lib_deps}
XPowersLib@^0.2.1
+[env:heltec_V3_boundary]
+platform = espressif32
+board = heltec_wifi_lora_32_V3
+custom_variant = heltec32v3
+board_build.filesystem = littlefs
+; Flash / memory layout for 8MB flash + 8MB PSRAM (QSPI)
+board_upload.flash_size = 8MB
+board_upload.maximum_size = 8388608
+board_build.partitions = default_8MB.csv
+board_build.flash_mode = qio
+board_build.psram_type = qio
+board_build.arduino.memory_type = qio_qspi
+monitor_speed = 115200
+build_flags =
+ ${env.build_flags}
+ -DBOARD_MODEL=BOARD_HELTEC32_V3
+ -DBOARD_HAS_PSRAM=1
+ -DBOUNDARY_MODE
+ ;-DNDEBUG
+ -DRNS_USE_TLSF=1
+ -DRNS_USE_ALLOCATOR=1
+ ; --- Boundary mode defaults (override via EEPROM at runtime) ---
+ ; TCP server mode (0=server, 1=client)
+ -DBOUNDARY_TCP_MODE=0
+ ; TCP listen/connect port
+ -DBOUNDARY_TCP_PORT=4242
+lib_deps =
+ ${env.lib_deps}
+ XPowersLib@^0.2.1
+monitor_filters = esp32_exception_decoder
+
[env:heltec_wifi_lora_32_V4]
platform = espressif32
board = esp32-s3-devkitc-1
@@ -342,7 +373,7 @@ build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DBOARD_HAS_PSRAM=1
-DBOUNDARY_MODE
- -DNDEBUG
+ ;-DNDEBUG
-DRNS_USE_TLSF=1
-DRNS_USE_ALLOCATOR=1
; --- Boundary mode defaults (override via EEPROM at runtime) ---
Served by rngit 1.5.0 - Generated in 0.04s